Skip to content

fix: serve the SPA shell for ssr:false apps in rsbuild dev#76

Merged
ScriptedAlchemy merged 14 commits into
mainfrom
claude/musing-shaw-924569
Jul 7, 2026
Merged

fix: serve the SPA shell for ssr:false apps in rsbuild dev#76
ScriptedAlchemy merged 14 commits into
mainfrom
claude/musing-shaw-924569

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Collaborator

Problem

ssr: false (SPA mode) apps returned HTTP 404 for every route in rsbuild dev, including /. Two things combined to cause this:

  • setupMiddlewares skipped the dev request middleware whenever ssr: false (pluginOptions.customServer || !ssr ? [] : [...])
  • all web entries set html: false, so no HTML document existed in dev at all

Production worked because the SPA build prerenders an index.html shell — and the examples/spa-mode e2e suite only tested build + static serve, so this was never caught.

Fix

React Router's request handler natively supports ssr: false server builds: when build.ssr is false (and no prerender paths are configured) it renders the SPA shell for any document request — the same shell the production build prerenders into index.html. The dev server build (with isSpaMode: true) is already compiled by the node environment, which always exists. So the fix is to drop the !ssr exclusion and register the dev middleware for SPA mode too.

Unknown paths return the app shell with a 404 status, so the client-side error boundary renders — matching React Router's Vite dev behavior.

Tests

  • Unit test asserting the middleware is registered when ssr: false (tests/features.test.ts)
  • New dev-mode Playwright suite for examples/spa-mode (playwright.dev.config.ts + tests/e2e-dev/) covering /, deep links to nested routes, shell hydration data, and client-side navigation. Kept as a separate config because the dev server writes into the same build/ directory the static suite asserts against; test:e2e now runs both, so CI picks it up.
  • Full unit suite green (441 tests), both spa-mode e2e suites pass (15 static + 4 dev), tsc --noEmit clean.

Benchmarks

Removes the devRoutes: 'none' workaround on synthetic-256-spa added in #75, so benchmarks measure SPA dev route serving again. Also waits for the node environment (not just web) before fetching SPA dev routes, since route serving now goes through the server build — otherwise node compile time would be misattributed to route-fetch latency. Verified with a real run: all 8 dev route fetches return 200 (~20–165 ms), including the post-HMR-update fetch.

🤖 Generated with Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/rsbuild-plugin-react-router@e400982

commit: e400982

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 60394b5b-7ed4-427a-bab5-68352c10e43a

📥 Commits

Reviewing files that changed from the base of the PR and between 629dd83 and 1a3c99f.

📒 Files selected for processing (10)
  • .changeset/lazy-spa-shell-dev.md
  • benchmarks/README.md
  • examples/spa-mode/package.json
  • examples/spa-mode/playwright.dev.config.ts
  • examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts
  • scripts/bench-builds.mts
  • scripts/benchmark/dev-server.mjs
  • scripts/benchmark/profiles.mjs
  • src/index.ts
  • tests/features.test.ts

📝 Walkthrough

Walkthrough

This PR enables dev-server setup for SPA mode, updates benchmark scripts and docs to match the new readiness behavior, and adds a Playwright dev-mode configuration plus e2e coverage for the SPA example. The example test script now runs both the default Playwright suite and the dev-mode suite. A changeset was added for a patch release noting SPA shell serving during rsbuild dev when ssr is disabled.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: serving the SPA shell in rsbuild dev for ssr:false apps.
Description check ✅ Passed The description accurately explains the problem, fix, tests, and benchmark updates reflected in the changeset.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/musing-shaw-924569

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts (1)

20-31: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two independent route checks combined in one test.

This test validates /about and then separately navigates to /docs/getting-started within the same test case. If the /about assertion fails, the nested-route coverage never runs, and failure diagnostics conflate two unrelated route checks.

♻️ Split into separate test cases
-  test('serves the SPA shell for deep links', async ({ page }) => {
-    const response = await page.goto('/about');
-
-    expect(response?.status()).toBe(200);
-    await expect(page.locator('h1:has-text("About This Demo")')).toBeVisible();
-
-    // Nested routes resolve as document requests too.
-    await page.goto('/docs/getting-started');
-    await expect(
-      page.locator('h1:has-text("Getting Started")')
-    ).toBeVisible();
-  });
+  test('serves the SPA shell for deep links', async ({ page }) => {
+    const response = await page.goto('/about');
+
+    expect(response?.status()).toBe(200);
+    await expect(page.locator('h1:has-text("About This Demo")')).toBeVisible();
+  });
+
+  test('serves the SPA shell for nested routes', async ({ page }) => {
+    await page.goto('/docs/getting-started');
+    await expect(
+      page.locator('h1:has-text("Getting Started")')
+    ).toBeVisible();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts` around lines 20 - 31,
The SPA deep-link coverage in test('serves the SPA shell for deep links')
combines two independent route checks, so split the /about assertion and the
/docs/getting-started assertion into separate test cases. Keep the existing
page.goto and expect checks, but isolate each route into its own test so
failures are independent and easier to diagnose.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/spa-mode/playwright.dev.config.ts`:
- Around line 29-34: The Playwright webServer setup is only probing the dev
port, so the SPA can still race before the compiler is ready. Update the
Playwright dev config by changing the readiness check in the webServer block for
the dev server command/url to wait for the actual dev-ready signal from rsbuild
rather than only checking http://localhost:3002, keeping the existing webServer
configuration structure intact.

In `@tests/features.test.ts`:
- Around line 36-49: Clear the global test stub after the SPA middleware test so
it does not leak into later cases. In the `should register the dev middleware
for SPA mode (ssr: false)` test, clean up `globalThis.__reactRouterTestConfig`
after `pluginReactRouter()`/`createStubRsbuild()` assertions, using the same
`__reactRouterTestConfig` symbol to reset the state and keep tests isolated.

---

Nitpick comments:
In `@examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts`:
- Around line 20-31: The SPA deep-link coverage in test('serves the SPA shell
for deep links') combines two independent route checks, so split the /about
assertion and the /docs/getting-started assertion into separate test cases. Keep
the existing page.goto and expect checks, but isolate each route into its own
test so failures are independent and easier to diagnose.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a8979cb-f831-4ede-81dd-dd2189270b5a

📥 Commits

Reviewing files that changed from the base of the PR and between c93ec98 and 629dd83.

📒 Files selected for processing (7)
  • examples/spa-mode/package.json
  • examples/spa-mode/playwright.dev.config.ts
  • examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts
  • scripts/bench-builds.mts
  • scripts/benchmark/profiles.mjs
  • src/index.ts
  • tests/features.test.ts
💤 Files with no reviewable changes (1)
  • scripts/benchmark/profiles.mjs

Comment thread examples/spa-mode/playwright.dev.config.ts
Comment thread tests/features.test.ts
@ScriptedAlchemy ScriptedAlchemy changed the base branch from main to claude/peaceful-benz-6eae2b July 3, 2026 21:11
@ScriptedAlchemy ScriptedAlchemy force-pushed the claude/musing-shaw-924569 branch from a5b36d5 to e400982 Compare July 3, 2026 21:11
Rsbuild 2.1 deprecates `dev.setupMiddlewares` in favor of `server.setup`.
Register the React Router dev middleware through a `server.setup` function
that returns a post-built-in-middlewares callback, preserving the ordering
guarantee that static assets are served before the request handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ScriptedAlchemy ScriptedAlchemy force-pushed the claude/peaceful-benz-6eae2b branch from b3c7fea to 7d2914b Compare July 3, 2026 21:30
ScriptedAlchemy and others added 3 commits July 3, 2026 21:33
SPA mode apps returned 404 for every route in dev because the plugin
skipped its dev middleware when ssr:false and all web entries disable
HTML generation, so no HTML document existed. React Router's request
handler natively renders the SPA shell for ssr:false builds, and the
dev server build (isSpaMode: true) is already compiled by the node
environment, so register the middleware for SPA mode too.

- Add a dev-mode Playwright suite to examples/spa-mode (separate
  config since the dev server writes into the build/ directory the
  static suite asserts against)
- Remove the devRoutes: 'none' benchmark override from PR #75 and
  wait for the node environment before fetching SPA dev routes, since
  route serving now depends on the server build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review cleanup: derive playwright.dev.config.ts from the sibling static
config instead of copying it, reuse the testGlobal pattern in
features.test.ts, fold the hydration-payload assertions into the shell
test to drop a duplicate navigation, and remove the per-profile
benchmark devRoutes override mechanism now that nothing produces it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The benchmark harness waits for an explicit, caller-provided set of
dev environments (['web', 'node'] for every fixture variant — SPA dev
route serving goes through the node server build too, so 'node' must
be ready before route fetches). The ready-line pattern captures any
environment name, and a startup timeout now reports which expected
environments were still awaited vs. observed instead of hanging
silently.

Also aligns the SPA-mode feature test with the server.setup
registration that now includes ssr:false apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ScriptedAlchemy ScriptedAlchemy force-pushed the claude/musing-shaw-924569 branch from e400982 to b3fce8d Compare July 3, 2026 21:36
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Benchmark Results

Compared PR head 1a3c99f against base 18fb279.

Dev Rollup

Group Fixtures Base total Head total Delta Base ready Head ready Ready delta Base routes Head routes Route delta Base update/HMR Head update/HMR Update delta Speedup
All dev fixtures 7 26.46s 26.47s +0.0% 17.69s 17.73s +0.2% 3.63s 3.71s +2.2% 2.98s 2.92s -2.1% 1.00x
Large app 1 12.45s 12.53s +0.7% 7.63s 7.68s +0.7% 1.78s 1.79s +0.7% 1.62s 1.65s +2.1% 0.99x
Standard fixtures 6 14.01s 13.94s -0.5% 10.07s 10.05s -0.1% 1.85s 1.92s +3.7% 1.36s 1.26s -7.1% 1.01x

Production Build Benchmarks

Rendered 7 production build benchmarks.

Benchmark Runs Base total Head total Delta Head mean Head p95 Speedup Head RSS p95
large-355-ssr-esm 5 8.05s 8.06s +0.0% 8.06s 8.19s 1.00x 1520 MB
synthetic-1024-ssr-esm 5 3.58s 3.67s +2.5% 3.72s 3.95s 0.98x 621 MB
synthetic-1024-ssr-esm-split 5 4.92s 4.87s -0.9% 4.92s 5.12s 1.01x 799 MB
synthetic-256-sourcemaps 10 1.95s 1.91s -1.9% 1.92s 2.07s 1.02x 429 MB
synthetic-256-ssr-esm 10 1.79s 1.79s +0.4% 1.80s 1.95s 1.00x 400 MB
synthetic-256-ssr-esm-split 10 2.16s 2.14s -0.9% 2.15s 2.29s 1.01x 441 MB
synthetic-48-ssr-esm 10 1.22s 1.22s -0.6% 1.23s 1.43s 1.01x 317 MB

full Dev Fixture Summary

Rendered 7 dev benchmark fixtures from the full profile.

Benchmark Runs Base total Head total Delta Base ready Head ready Base routes Head routes Base update/HMR Head update/HMR Update delta Head mean Head p95 Speedup Head RSS p95
large-355-ssr-esm 5 12.45s 12.53s +0.7% 7.63s 7.68s 1.78s 1.79s 1.62s 1.65s +2.1% 12.55s 12.67s 0.99x -
synthetic-1024-ssr-esm 5 4.15s 4.15s +0.0% 2.98s 2.99s 0.56s 0.56s 0.43s 0.43s -0.2% 4.13s 4.18s 1.00x -
synthetic-1024-ssr-esm-split 5 4.09s 4.06s -0.9% 2.90s 2.91s 0.51s 0.55s 0.48s 0.41s -14.8% 4.08s 4.13s 1.01x -
synthetic-256-sourcemaps 10 1.81s 1.80s -0.3% 1.33s 1.34s 0.24s 0.22s 0.13s 0.13s +0.3% 1.79s 1.84s 1.00x -
synthetic-256-ssr-esm 10 1.57s 1.56s -0.8% 1.13s 1.12s 0.22s 0.24s 0.15s 0.13s -16.1% 1.55s 1.59s 1.01x -
synthetic-256-ssr-esm-split 10 1.57s 1.56s -0.9% 1.14s 1.12s 0.22s 0.23s 0.13s 0.13s -1.2% 1.57s 1.61s 1.01x -
synthetic-48-ssr-esm 10 0.82s 0.81s -1.0% 0.59s 0.57s 0.11s 0.12s 0.05s 0.05s +0.3% 0.81s 0.84s 1.01x -

large-355-ssr-esm Plugin Operations

Environment Operation Count Base total Head total Delta Head wall Head max Reports
web route:module 1785 1733.7ms 1678.3ms -3.2% 1678.3ms 23.7ms 10
node route:module 1785 859.7ms 835.2ms -2.8% 835.2ms 11.7ms 10
web route:client-entry 1785 356.7ms 332.3ms -6.8% 332.3ms 5.0ms 10
node manifest:transform 5 107.8ms 106.0ms -1.7% 106.0ms 27.1ms 5
web manifest:stage 10 13.9ms 13.8ms -0.7% 13.8ms 1.9ms 10
web manifest:transform 5 0.5ms 0.5ms 0.0% 0.5ms 0.1ms 5

synthetic-1024-ssr-esm Plugin Operations

Environment Operation Count Base total Head total Delta Head wall Head max Reports
web route:module 5130 1944.6ms 1971.4ms +1.4% 1971.4ms 18.2ms 10
node route:module 5130 917.2ms 919.0ms +0.2% 919.0ms 12.7ms 10
web route:client-entry 5130 565.2ms 566.2ms +0.2% 566.2ms 6.0ms 10
node manifest:transform 5 177.4ms 187.6ms +5.7% 187.6ms 38.2ms 5
web manifest:stage 10 56.4ms 57.2ms +1.4% 57.2ms 9.0ms 10
node module:client-only-stub 5 55.0ms 122.0ms +121.8% 122.0ms 43.2ms 5
web manifest:transform 5 0.5ms 0.5ms 0.0% 0.5ms 0.1ms 5

synthetic-1024-ssr-esm-split Plugin Operations

Environment Operation Count Base total Head total Delta Head wall Head max Reports
web route:module 5130 1915.3ms 1986.9ms +3.7% 1986.9ms 36.6ms 10
node route:module 5130 931.7ms 914.3ms -1.9% 914.3ms 5.4ms 10
web route:client-entry 5130 577.3ms 550.3ms -4.7% 550.3ms 6.3ms 10
node module:client-only-stub 5 282.0ms 186.8ms -33.8% 186.8ms 79.3ms 5
node manifest:transform 5 193.7ms 186.3ms -3.8% 186.3ms 41.0ms 5
web manifest:stage 10 70.5ms 56.5ms -19.9% 56.5ms 9.0ms 10
web manifest:transform 5 0.5ms 0.5ms 0.0% 0.5ms 0.1ms 5

synthetic-256-sourcemaps Plugin Operations

Environment Operation Count Base total Head total Delta Head wall Head max Reports
web route:module 2583 1335.5ms 1312.7ms -1.7% 1312.7ms 18.6ms 23
node route:module 2580 570.3ms 596.5ms +4.6% 596.5ms 4.1ms 20
web route:client-entry 2583 353.2ms 366.3ms +3.7% 366.3ms 4.5ms 23
node module:client-only-stub 10 240.7ms 128.3ms -46.7% 128.3ms 41.6ms 10
node manifest:transform 10 145.8ms 152.5ms +4.6% 152.5ms 19.8ms 10
web manifest:stage 23 21.5ms 22.1ms +2.8% 22.1ms 1.6ms 23
web manifest:transform 10 0.9ms 1.0ms +11.1% 1.0ms 0.1ms 10

synthetic-256-ssr-esm Plugin Operations

Environment Operation Count Base total Head total Delta Head wall Head max Reports
web route:module 2580 1207.2ms 1285.1ms +6.5% 1285.1ms 14.4ms 20
node route:module 2580 547.4ms 513.6ms -6.2% 513.6ms 8.7ms 20
web route:client-entry 2580 358.5ms 374.1ms +4.4% 374.1ms 4.7ms 20
node module:client-only-stub 10 195.1ms 182.4ms -6.5% 182.4ms 132.7ms 10
node manifest:transform 10 137.4ms 134.0ms -2.5% 134.0ms 16.6ms 10
web manifest:stage 20 20.2ms 20.4ms +1.0% 20.4ms 1.4ms 20
web manifest:transform 10 1.0ms 1.0ms 0.0% 1.0ms 0.1ms 10

synthetic-256-ssr-esm-split Plugin Operations

Environment Operation Count Base total Head total Delta Head wall Head max Reports
web route:module 2582 1241.4ms 1295.6ms +4.4% 1295.6ms 14.3ms 22
node route:module 2580 528.5ms 527.3ms -0.2% 527.3ms 4.6ms 20
web route:client-entry 2582 361.1ms 365.7ms +1.3% 365.7ms 5.5ms 22
node manifest:transform 10 136.4ms 167.9ms +23.1% 167.9ms 20.1ms 10
node module:client-only-stub 10 118.6ms 207.3ms +74.8% 207.3ms 90.2ms 10
web manifest:stage 22 20.2ms 21.5ms +6.4% 21.5ms 1.3ms 22
web manifest:transform 10 1.0ms 1.0ms 0.0% 1.0ms 0.1ms 10

synthetic-48-ssr-esm Plugin Operations

Environment Operation Count Base total Head total Delta Head wall Head max Reports
web route:module 500 447.3ms 407.9ms -8.8% 407.9ms 8.7ms 20
node route:module 500 143.2ms 145.7ms +1.7% 145.7ms 0.6ms 20
node module:client-only-stub 10 101.7ms 86.3ms -15.1% 86.3ms 12.5ms 10
web route:client-entry 500 97.4ms 106.4ms +9.2% 106.4ms 3.0ms 20
node manifest:transform 10 56.4ms 44.7ms -20.7% 44.7ms 5.6ms 10
web manifest:stage 20 5.0ms 5.2ms +4.0% 5.2ms 0.4ms 20
web manifest:transform 10 0.9ms 1.0ms +11.1% 1.0ms 0.1ms 10

Synthetic Rsbuild App

Rendered 2 production build benchmarks.

Benchmark Runs Base total Head total Delta Head mean Head p95 Speedup Head RSS p95
complex app 2 105.45s 105.85s +0.4% 105.85s - 1.00x -
complex app 2 73.10s 72.44s -0.9% 72.44s - 1.01x -

Rendered 1 dev benchmark fixture from the embedded complex app.

Benchmark Runs Base total Head total Delta Base ready Head ready Base routes Head routes Base update/HMR Head update/HMR Update delta Head mean Head p95 Speedup Head RSS p95
complex app 2 88.64s 89.33s +0.8% 80.81s 81.44s 2.55s 2.56s 3.12s 3.11s -0.3% 89.33s - 0.99x -

Profile: full; mode: dev; iterations: 10; warmup: 0.
The uploaded benchmark artifact includes diagnostics/summary.md and diagnostics/summary.json with runner metadata, per-run timing samples, CPU/RSS samples, and plugin timing hot spots.
Workflow run

Base automatically changed from claude/peaceful-benz-6eae2b to main July 7, 2026 05:16
# Conflicts:
#	src/index.ts
#	tests/features.test.ts
@ScriptedAlchemy ScriptedAlchemy merged commit 9e95ea0 into main Jul 7, 2026
4 checks passed
@ScriptedAlchemy ScriptedAlchemy deleted the claude/musing-shaw-924569 branch July 7, 2026 05:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant